Explore TypeScript Template Literal Types and build a runtime validation engine for robust string verification and type safety. Learn to prevent errors by validating strings against your defined template literal types at runtime.
TypeScript Template Literal Validation Engine: Runtime String Verification
TypeScript's template literal types offer powerful compile-time string manipulation and type safety. However, these checks are limited to compile time. This blog post explores how to build a runtime validation engine for TypeScript template literal types, enabling robust string verification and preventing potential errors during program execution.
Introduction to TypeScript Template Literal Types
Template literal types allow you to define specific string shapes based on literal values, unions, and type inference. This enables precise type checking and auto-completion, especially useful when dealing with structured data or domain-specific languages.
For example, consider a type for representing currency codes:
type CurrencyCode = "USD" | "EUR" | "GBP";
type FormattedCurrencyString = `${CurrencyCode}-${number}`;
const validCurrency: FormattedCurrencyString = "USD-100"; // OK
const invalidCurrency: FormattedCurrencyString = "CAD-50"; // Type error at compile time
This example demonstrates how TypeScript enforces the FormattedCurrencyString type at compile time. However, if the currency code comes from an external source (e.g., user input, API response), you need runtime validation to ensure type safety.
The Need for Runtime Validation
While TypeScript provides excellent compile-time type checking, it cannot guarantee the validity of data received from external sources at runtime. Relying solely on compile-time types can lead to unexpected errors and vulnerabilities.
Consider the following scenario:
function processCurrency(currencyString: FormattedCurrencyString) {
// ... some logic that assumes the string is correctly formatted
}
const userInput = "CAD-50"; // Assume this comes from user input
// This will compile, but will cause a runtime error if the logic inside
// `processCurrency` relies on the format.
processCurrency(userInput as FormattedCurrencyString);
In this case, we are casting userInput to FormattedCurrencyString, bypassing TypeScript's compile-time checks. If processCurrency relies on the string being formatted correctly, it will encounter a runtime error.
Runtime validation bridges this gap by verifying that the data received at runtime conforms to the expected TypeScript types.
Building a Template Literal Validation Engine
We can build a runtime validation engine using regular expressions and TypeScript's type system. The engine will take a template literal type and a string as input and return whether the string matches the type.
Step 1: Defining a Type for Runtime Validation
First, we need a generic type that can represent the runtime equivalent of a template literal type. This type should be able to handle different kinds of template literals, including literals, unions, and type parameters.
type TemplateLiteralToRegex =
T extends `${infer Start}${infer Middle}${infer End}`
? Start extends string
? Middle extends string
? End extends string
? TemplateLiteralToRegexStart & TemplateLiteralToRegexMiddle & TemplateLiteralToRegex
: never
: never
: never
: TemplateLiteralToRegexStart;
type TemplateLiteralToRegexStart = T extends `${infer Literal}` ? Literal : string;
type TemplateLiteralToRegexMiddle = T extends `${infer Literal}` ? Literal : string;
This recursive type definition breaks down the template literal into its constituent parts and converts each part into a regular expression pattern.
Step 2: Implementing the Validation Function
Next, we implement the validation function that takes the template literal type and the string to validate as input. This function uses the regular expression generated by TemplateLiteralToRegex to test the string.
function isValid(str: string, templateType: T): boolean {
const regexPattern = `^${convertTemplateLiteralToRegex(templateType)}$`;
const regex = new RegExp(regexPattern);
return regex.test(str);
}
function convertTemplateLiteralToRegex(templateType: T): string {
// Basic conversion for literal strings - extend this for more complex scenarios
return templateType.replace(/[.*+?^${}()|\[\]]/g, '\\$&'); // Escape special regex characters
}
This function escapes special regular expression characters and creates a regular expression from the template literal type, then tests the string against that regular expression.
Step 3: Using the Validation Engine
Now, you can use the isValid function to validate strings against your template literal types at runtime.
type CurrencyCode = "USD" | "EUR" | "GBP";
type FormattedCurrencyString = `${CurrencyCode}-${number}`;
const userInput1 = "USD-100";
const userInput2 = "CAD-50";
console.log(`'${userInput1}' is valid: ${isValid(userInput1, "USD-100" )}`); // true
console.log(`'${userInput2}' is valid: ${isValid(userInput2, "USD-100")}`); // false
console.log(`'${userInput1}' is valid: ${isValid(userInput1, `USD-${100}`)}`); // true
console.log(`'${userInput2}' is valid: ${isValid(userInput2, `USD-${100}`)}`); // false
This example demonstrates how to use the isValid function to validate user input against the FormattedCurrencyString type. The output will show whether the input strings are considered valid or not, based on the specified template literal.
Advanced Validation Scenarios
The basic validation engine can be extended to handle more complex scenarios, such as unions, conditional types, and recursive types.
Handling Unions
To handle unions, you can modify the TemplateLiteralToRegex type to generate a regular expression that matches any of the union members.
type CurrencyCode = "USD" | "EUR" | "GBP";
type FormattedCurrencyString = `${CurrencyCode}-${number}`;
function isValidCurrencyCode(str: string, templateType: T): boolean {
const currencyCodes: CurrencyCode[] = ["USD", "EUR", "GBP"];
return currencyCodes.includes(str as CurrencyCode);
}
function isValidUnionFormattedCurrencyString(str: string): boolean {
const parts = str.split('-');
if(parts.length !== 2) return false;
const [currencyCode, amount] = parts;
if (!isValidCurrencyCode(currencyCode, currencyCode)) return false;
if (isNaN(Number(amount))) return false;
return true;
}
console.log(`'USD-100' is valid formatted string: ${isValidUnionFormattedCurrencyString('USD-100')}`);
console.log(`'CAD-50' is valid formatted string: ${isValidUnionFormattedCurrencyString('CAD-50')}`);
Handling Conditional Types
Conditional types can be handled by evaluating the condition at runtime and generating different regular expressions based on the result.
type IsString = T extends string ? true : false;
// This example requires more advanced logic and isn't fully implementable using simple regex.
// Runtime type guards offer a more robust solution in this specific scenario.
// The below code is illustrative and would need adaptation to handle complex conditional types.
function isString(value: any): value is string {
return typeof value === 'string';
}
function isValidConditionalType(value: any): boolean {
return isString(value);
}
console.log(`'hello' is a string: ${isValidConditionalType('hello')}`);
console.log(`123 is a string: ${isValidConditionalType(123)}`);
Handling Recursive Types
Recursive types can be handled by defining a recursive function that generates the regular expression pattern. However, be careful to avoid infinite recursion and stack overflow errors. For deep recursion, iterative approaches with appropriate limits are crucial.
Alternatives to Regular Expressions
While regular expressions are a powerful tool for string validation, they can be complex and difficult to maintain. Other approaches to runtime validation include:
- Custom Validation Functions: Write custom functions to validate specific types based on your application's requirements.
- Type Guards: Use type guards to narrow down the type of a variable at runtime.
- Validation Libraries: Leverage existing validation libraries like Zod or Yup to simplify the validation process.
Zod, for instance, provides a schema-based declaration that translates to a validation runtime:
import { z } from 'zod';
const CurrencyCodeSchema = z.enum(['USD', 'EUR', 'GBP']);
const FormattedCurrencyStringSchema = z.string().regex(new RegExp(`^${CurrencyCodeSchema.enum.USD}|${CurrencyCodeSchema.enum.EUR}|${CurrencyCodeSchema.enum.GBP}-[0-9]+$`));
try {
const validCurrency = FormattedCurrencyStringSchema.parse("USD-100");
console.log("Valid Currency:", validCurrency);
} catch (error) {
console.error("Invalid Currency:", error);
}
try {
const invalidCurrency = FormattedCurrencyStringSchema.parse("CAD-50");
console.log("Valid Currency:", invalidCurrency); //This won't execute if parse fails.
} catch (error) {
console.error("Invalid Currency:", error);
}
Best Practices for Runtime Validation
When implementing runtime validation, keep the following best practices in mind:
- Validate at the Boundary: Validate data as soon as it enters your system (e.g., user input, API responses).
- Provide Clear Error Messages: Generate informative error messages to help users understand why their input is invalid.
- Use a Consistent Validation Strategy: Adopt a consistent validation strategy across your application to ensure data integrity.
- Test Your Validation Logic: Thoroughly test your validation logic to ensure that it correctly identifies valid and invalid data.
- Balance Performance and Security: Optimize your validation logic for performance while ensuring that it effectively prevents security vulnerabilities. Avoid overly complex regex that leads to denial of service.
Internationalization Considerations
When dealing with string validation in a global context, you need to consider internationalization (i18n) and localization (l10n). Different locales may have different rules for formatting strings, such as dates, numbers, and currency values.
For example, the currency symbol for the Euro (€) may appear before or after the amount, depending on the locale. Similarly, the decimal separator may be a period (.) or a comma (,).
To handle these variations, you can use internationalization libraries like Intl, which provides APIs for formatting and parsing locale-sensitive data. For instance, you could adapt the previous example to handle different currency formats:
function isValidCurrencyString(currencyString: string, locale: string): boolean {
try {
const formatter = new Intl.NumberFormat(locale, { style: 'currency', currency: currencyString.substring(0,3) }); //Very basic example
//Attempt to parse the currency using formatter. This example is intentionally very simple.
return true;
} catch (error) {
return false;
}
}
console.log(`USD-100 is valid for en-US: ${isValidCurrencyString('USD-100', 'en-US')}`);
console.log(`EUR-100 is valid for fr-FR: ${isValidCurrencyString('EUR-100', 'fr-FR')}`);
This code snippet provides a foundational example. Proper internationalization requires more thorough handling, potentially utilizing external libraries or APIs specifically designed for currency formatting and validation across different locales.
Conclusion
Runtime validation is an essential part of building robust and reliable TypeScript applications. By combining TypeScript's template literal types with regular expressions or alternative validation methods, you can create a powerful engine for verifying the validity of strings at runtime.
This approach enhances type safety, prevents unexpected errors, and improves the overall quality of your code. As you build more complex applications, consider incorporating runtime validation to ensure that your data conforms to the expected types and formats.
Further Exploration
- Explore advanced regular expression techniques for more complex validation scenarios.
- Investigate validation libraries like Zod and Yup for schema-based validation.
- Consider using code generation techniques to automatically generate validation functions from TypeScript types.
- Study internationalization libraries and APIs to handle locale-sensitive data.